// This file is part of SyncStar, free software released under GPL v2.
package com.syncstar;

import android.content.ContentResolver;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.util.Log;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/** Helper class for reading and writing the SMS message database of the phone.
 */
class SmsUtils {
  private static final String TAG = SmsUtils.class.getSimpleName();

  // http://stackoverflow.com/questions/7149015/what-is-the-correct-content-uri-for-the-sms-thread-table\
  // content://sms/conversations" gives me the columns:
  // * [thread_id, msg_count, snippet]
  // Querying the URI "content://mms-sms/conversations" gives me the columns:
  // * [body, person, sub, subject, retr_st, type, date, ct_cls, sub_cs, _id,
  // read, ct_l, tr_id, st, msg_box, thread_id, reply_path_present, m_cls,
  // read_status, ct_t, status, retr_txt_cs, d_rpt, error_code, m_id, m_type,
  // v, exp, pri, service_center, address, rr, rpt_a, resp_txt, locked, resp_st, m_size]
  static final Uri SMS_PROVIDER = Uri.parse("content://sms");
  static final Uri CONVERSATION_PROVIDER =
      Uri.parse("content://mms-sms/conversations");
  static final Uri SIMPLE_CONVERSATION_PROVIDER = CONVERSATION_PROVIDER
      .buildUpon().appendQueryParameter("simple", "true").build();
  static final Uri ALL_CANONICAL_PROVIDER =
            Uri.parse("content://mms-sms/canonical-addresses");
  static final String[] SMS_COLUMNS = new String[] {
      "address", "body", "service_center", "date", "error_code", "locked",
      "read", "seen", "type", "protocol", "status", "thread_id"};
  static final int SMS_COLUMNS_STRING_COUNT = 3;
  static final int SMS_COLUMNS_LONG_COUNT = 1;
  static final int SMS_COLUMNS_INT_COUNT = 6;
  static final int SMS_COLUMNS_INTM1_COUNT = 1;

  @SuppressWarnings({"UnusedDeclaration"})
  static final int MESSAGE_TYPE_ALL = 0;
  static final int MESSAGE_TYPE_INBOX = 1;
  @SuppressWarnings({"UnusedDeclaration"})
  static final int MESSAGE_TYPE_SENT = 2;
  @SuppressWarnings({"UnusedDeclaration"})
  static final int MESSAGE_TYPE_DRAFT = 3;
  @SuppressWarnings({"UnusedDeclaration"})
  static final int MESSAGE_TYPE_OUTBOX = 4;
  @SuppressWarnings({"UnusedDeclaration"})
  static final int MESSAGE_TYPE_FAILED = 5; // for failed outgoing messages
  @SuppressWarnings({"UnusedDeclaration"})
  static final int MESSAGE_TYPE_QUEUED = 6; // for messages to send later

  static interface ShortSmsDataReceiver {
    void receiveShortSmsData(String address, String body, long date, int type,
                             int read, int seen,
                             long threadId, boolean isAddressEmpty);
  }

  static JSONObject getPhoneSmsData(
      Context context, final ShortSmsDataReceiver receiver) {
    JSONObject req = new JSONObject();
    SharedPreferences spConfig = context.getSharedPreferences("config", 0);
    try {
      req.put("phone", spConfig.getString("phone", "ap"));
      Log.e(TAG, "CONNX Phone name: " + spConfig.getString("phone", "ap"));
      final JSONArray ary = new JSONArray();
      req.put("messages", ary);
      iterMessages(context.getContentResolver(), false, "", new MessageReceiver() {
        public void receiveMessage(JSONObject message, long threadId,
                                   boolean isAddressEmpty) {
          ary.put(message);
          if (receiver != null) {
            try {
              receiver.receiveShortSmsData(
                  message.getString("address"),
                  message.getString("body"),
                  message.getLong("date"),
                  message.getInt("type"),
                  message.getInt("read"),
                  message.getInt("seen"),
                  threadId, isAddressEmpty);
            } catch (JSONException e) {
              throw new RuntimeException("showSmses presenceEntry JSON", e);
            }
          }
        }
      });
    } catch (JSONException e) {
      throw new RuntimeException("showSmses JSON", e);
    }
    return req;
  }

  static Uri buildFolderUri(String folder) {
    Uri.Builder builder = SMS_PROVIDER.buildUpon();
    builder.appendPath(folder);
    Uri uri = builder.build();
    Log.v("URI tag", "Built SMS URI: " + uri);
    return uri;
  }

  static int parseFirstUnsignedDecimal(String s) {
    char c = 0;
    int i = 0;
    int size = s.length();
    while (i < size && Character.isWhitespace(c = s.charAt(i))) {
      ++i;
    }
    if (i == size || c < '0' || c > '9')
      throw new NumberFormatException(s);
    int num = c - '0';
    while (++i < size) {
      if ((c = s.charAt(i)) >= '0' && c <= '9') {
        num = 10 * num + c - '0';  // TODO: Check for overflow.
      } else if (c == ',' || Character.isWhitespace(c)) {
        break;
      } else {
        throw new NumberFormatException(s);
      }
    }
    return num;
  }

  static long getOrCreateThreadId(final Context context, final String recipient) {
    Class telephonyThreads;
    Method getOrCreateThreadId;
    try {
      telephonyThreads = Class.forName("android.provider.Telephony$Threads");
      getOrCreateThreadId = telephonyThreads.getMethod("getOrCreateThreadId",
              Context.class, String.class);
    } catch (NoSuchMethodException e) {
      throw new RuntimeException("getThreadId", e);
    } catch (ClassNotFoundException e) {
      throw new RuntimeException("getThreadId", e);
    }
    try {
      //noinspection RedundantCast
      return (long)(Long)getOrCreateThreadId.invoke(telephonyThreads,
              context, recipient);
    } catch (InvocationTargetException e) {
      throw new RuntimeException("getThreadId", e);
    } catch (IllegalAccessException e) {
      throw new RuntimeException("getThreadId", e);
    }
  }

  /** Does not convert conversation to thread_id. Use other methods for that.
   * @param smessage smessage
   * @param areKeysRequired areKeysRequired
   * @return A new ContentValues object.
   */
  static ContentValues convertJsonMessageToContentValues(
      JSONObject smessage, boolean areKeysRequired) {
    ContentValues contentValues = new ContentValues();
    try {
      // TODO: Accept only integers -- optInt tries to coerce.
      if (areKeysRequired) {
        // The get*() methods below throw JSONException if the key is not found
        // or cannot be coerced. Good.
        contentValues.put("address", smessage.getString("address"));
        contentValues.put("body", smessage.getString("body"));
        contentValues.put("date", smessage.getLong("date"));
        contentValues.put("type", smessage.getInt("type"));
      } else {
        long date = smessage.optLong("date", Long.MIN_VALUE);
        if (date == Long.MIN_VALUE)
          date = System.currentTimeMillis();
        contentValues.put("address", smessage.optString("address", ""));
        contentValues.put("body", smessage.optString("body", ""));
        contentValues.put("date", date);
        contentValues.put("type", smessage.optInt("type", 1));
      }
      // contentValues.put("thread_id", getOrCreateThreadId(context, smessage.optString("conversation", "")));
      String conversation = smessage.optString("conversation", "");
      if (0 == conversation.length())
        conversation = contentValues.getAsString("address");
      contentValues.put("conversation", conversation);
      contentValues.put("service_center", smessage.optString("service_center", ""));
      contentValues.put("error_code", smessage.optInt("error_code", 0));
      contentValues.put("locked", smessage.optInt("locked", 0));
      contentValues.put("read", smessage.optInt("read" ,0));
      contentValues.put("seen", smessage.optInt("seen", 0));
      contentValues.put("status", smessage.optInt("status", -1));
      contentValues.put("protocol", smessage.optInt("protocol", 0));
      contentValues.put("deleted", smessage.optBoolean("deleted", false));
      return contentValues;
    } catch (JSONException e) {
      // throw new RuntimeException("convertJsonMessageToContentValues", e);
      return null;
    }
  }

  static String convertConversationToThreadIdInContentValues(
      Context context, ContentValues contentValues) {
    String conversation = contentValues.getAsString("conversation");
    if (conversation == null || 0 == conversation.length()) {
      conversation = contentValues.getAsString("address");
      if (conversation == null)
        throw new RuntimeException("Missing address field.");
    }
    contentValues.put("thread_id",
                      getOrCreateThreadId(context, conversation));
    contentValues.remove("conversation");
    return conversation;
  }

  interface MessageReceiver {
    void receiveMessage(JSONObject message, long threadId,
                        boolean isAddressEmpty);
  }

  @SuppressWarnings({"ConstantConditions"})
  static void iterMessages(ContentResolver contentResolver,
                           boolean unreadOnly, String folder,
                           MessageReceiver messageReceiver) {
    Uri uri = buildFolderUri(folder);
    String selection = unreadOnly ? "read = 0" : "";
    try {
      Cursor cursor = contentResolver.query(uri, SMS_COLUMNS, selection,
          null, null);
      if (cursor != null) {
        try {
          Map<Long, Integer> threadMap = generateThreadMap(contentResolver);
          Map<Integer, String> recipientMap = generateRecipientMap(
              contentResolver);
          // TODO: Write method to combine these 2 maps.
          while (cursor.moveToNext()) {
            JSONObject message = new JSONObject();
            int i = 0;
            int j = i + SMS_COLUMNS_STRING_COUNT;
            for (; i < j; i++) {
              if (!cursor.isNull(i) && cursor.getString(i).length() > 0) {
                message.put(SMS_COLUMNS[i], cursor.getString(i));
              } else {
                message.put(SMS_COLUMNS[i], "");
              }
            }
            j = i + SMS_COLUMNS_LONG_COUNT;
            for (; i < j; i++) {
              if (!cursor.isNull(i) && cursor.getLong(i) != 0) {
                message.put(SMS_COLUMNS[i], cursor.getLong(i));
              } else {
                message.put(SMS_COLUMNS[i], 0L);
              }
            }
            j = i + SMS_COLUMNS_INT_COUNT;
            for (; i < j; i++) {
              if (!cursor.isNull(i) && cursor.getInt(i) != 0) {
                message.put(SMS_COLUMNS[i], cursor.getInt(i));
              } else {
                message.put(SMS_COLUMNS[i], 0);
              }
            }
            j = i + SMS_COLUMNS_INTM1_COUNT;
            for (; i < j; i++) {
              if (!cursor.isNull(i) && cursor.getInt(i) != -1) {
                message.put(SMS_COLUMNS[i], cursor.getInt(i));
              } else {
                message.put(SMS_COLUMNS[i], -1);
              }
            }
            final long threadId = cursor.getInt(i);
            final Integer recipientId = threadMap.get(threadId);
            final boolean isAddressEmpty =
                message.getString("address").length() == 0;
            if (recipientId != null) {
              final String conversation = recipientMap.get(recipientId);
              if (conversation != null &&
                  conversation.length() > 0 &&
                  !conversation.equals(cursor.getString(1))) { // "address".
                message.put("conversation", conversation);
                if (isAddressEmpty) {
                  // The Messaging app fills the conversation but not the address
                  // of draft messages. So we fill the address.
                  message.put("address", conversation);
                }
              }
            }
            // Log.e(TAG, "CONNX msg: " + message);
            messageReceiver.receiveMessage(message, threadId, isAddressEmpty);
          }
        } finally {
          cursor.close();
        }
      }
    } catch (JSONException e) {
      throw new RuntimeException("getSmsMessages JSON", e);
    }
  }

  static Map<Long, Integer> generateThreadMap(
      ContentResolver contentResolver) {
    Map<Long, Integer> result = new HashMap<Long, Integer>();
    final String selection = null;  // TODO: Get rid of this.
    final String[] columns = new String[] { "_id", "recipient_ids" };  // TODO: Move this to a class-level constant.
    final Uri provider = SIMPLE_CONVERSATION_PROVIDER;  // TODO: Get rid of this.
    Cursor cursor = contentResolver.query(
        provider, columns, selection, null, null);
    if (cursor != null) {
      try {
        while (cursor.moveToNext()) {
          result.put(
              cursor.getLong(0),
              // `recipient_ids' is a string of space-separated decimal numbers.
              // We're interested in the first number only.
              // The Messaging application displays all recipients in the message
              // list.
              parseFirstUnsignedDecimal(cursor.getString(1)));
        }
      } finally {
        cursor.close();
      }
    }
    return result;
  }

  static Map<Integer, String> generateRecipientMap(
      ContentResolver contentResolver) {
    Map<Integer, String> result = new HashMap<Integer, String>();
    final String selection = "";
    final String[] columns = new String[] { "_id", "recipient" };
    final Uri provider = ALL_CANONICAL_PROVIDER;
    Cursor cursor = contentResolver.query(
        provider, columns, selection, null, null);
    if (cursor != null) {
      try {
        while (cursor.moveToNext()) {
          result.put(cursor.getInt(0), cursor.getString(1));
        }
      } finally {
        cursor.close();
      }
    }
    return result;
  }

  static class PresenceOpenHelper extends SQLiteOpenHelper {
    static final int DATABASE_VERSION = 1;
    static final String DATABASE_NAME = "PresenceDb";
    static final String PRESENCE_TABLE_NAME = "PresenceEntry";
    static final String PRESENCE_TABLE_CREATE =
        "CREATE TABLE " + PRESENCE_TABLE_NAME + " (" +
        "address TEXT NOT NULL, body TEXT NOT NULL, " +
        "date INT NOT NULL, type INT NOT NULL, " +
        "CONSTRAINT PresenceEntryKey UNIQUE (address, body, date, type))";
    static final String[] PRESENCE_COLUMNS = new String[] {
        "address", "body", "date", "type" };

    PresenceOpenHelper(Context context) {
      super(context, DATABASE_NAME, null, DATABASE_VERSION);
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
      Log.e(TAG, "CONNX create");
      db.setLockingEnabled(false);  // It's true by default.
      db.execSQL(PRESENCE_TABLE_CREATE);
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
      Log.e(TAG, "CONNX onUpgrade");
      db.setLockingEnabled(false);  // It's true by default.
      db.execSQL("DROP TABLE IF EXISTS " + PRESENCE_TABLE_NAME);
      onCreate(db);
    }

    @Override
    public void onOpen(SQLiteDatabase db) {
      Log.e(TAG, "CONNX onOpen");
      db.setLockingEnabled(false);  // It's true by default.
      super.onOpen(db);
    }
  }

  static private PresenceOpenHelper presenceOpenHelper_;

  static private synchronized PresenceOpenHelper getPresenceOpenHelper(
      Context context) {
    if (presenceOpenHelper_ == null) {
      // We don't grab presenceLock_, it's not needed by presenceOpenHelper_.
      presenceOpenHelper_ = new PresenceOpenHelper(context);
    }
    return presenceOpenHelper_;
  }

  /** presenceLock_ is held by each function operating on the presence database.
   *
   * This may sound an overkill, because if all functions are readers, then they
   * wouldn't disturb each other without locking. However, we play it safe here
   * by locking conservatively.
   *
   * Please note that each instance of SQLiteOpenHelper has a cached copy of an
   * SQLiteDatabase, and each SQLiteDatabase has a filehandle open pointing to
   * the .sqlite file. The SQLite C library uses file-level locking on the file
   * to prevent disruptive concurrent access. Locking is coupled with
   * transactions. SQLite supports DEFERRED (default), IMMEDIATE and
   * EXCLUSIVE transactions. The Android API supports only IMMEDIATE (using
   * SQLiteDatabase.beginTransactionNonExclusive()) and EXCLUSiVE (using
   * SQLiteDatabase.beginTransaction()). An EXCLUSIVE transaction blocks other
   * readers and writers.
   *
   * We must not create a new database instance using
   * SQLiteOpenHelper.getWritableDatabase() when another thread is
   * accessing the database (using an SQLiteDatabase reference obtained from an
   * existing SQLiteOpenHelper), because SQLiteOpenHelper.getWritableDatabase()
   * may call onCreate or onUpdate, and it always runs the query in
   * SQLiteDatabase.getVersion, and without locking it might immediately raise
   * an SQLiteException with "error code 5: database is locked". So we call
   * SQLiteOpenHelper.getWritableDatabase() only when presenceLock_ is held.
   *
   * Please note that there is SQLiteDatabase.enableWriteAheadLogging(), which
   * allows for parallel queries (by opening a separate filehandle for each
   * query and letting the SQLite C library take care of parallel operation.
   * We don't use that feature.
   *
   * Please note that there is SQLiteDatabase.setLockingEnabled(true) by
   * default, which does Java-level locking on each query. We set it to false,
   * because our usage of presenceLock_ makes sure that we don't run parallel
   * queries.
   */
  static private Lock presenceLock_ = new ReentrantLock();

  /** The caller should call db.beginTransaction().
   * @param contentResolver contentResolver
   * @param db db
   */
  static void populatePresenceTable(ContentResolver contentResolver,
                                    final SQLiteDatabase db) {
    presenceLock_.lock();
    try {
      Log.e(TAG, "CONNX populatePresenceTable");
      iterMessages(contentResolver, false, "", new MessageReceiver() {
        public void receiveMessage(JSONObject message, long threadId,
                                   boolean isAddressEmpty) {
          try {
            // TODO: Use insertWithOnConflict(String table,
            // String nullColumnHack, ContentValues initialValues,
            // int conflictAlgorithm)
            db.execSQL("INSERT OR IGNORE INTO PresenceEntry " +
                "(address, body, date, type) VALUES (?, ?, ?, ?)",
                new Object[]{
                    message.getString("address"),
                    message.getString("body"),
                    message.getLong("date"),
                    message.getInt("type"),
                });
          } catch (JSONException e) {
            throw new RuntimeException("populatePresenceTable JSON", e);
          }
        }
      });
    } finally {
      presenceLock_.unlock();
    }
  }

  static void populatePresenceTable(Context context, int recentMs) {
    SharedPreferences spLastpopulate = context.getSharedPreferences("lastpopulate", 0);
    long lastPopulateAt = spLastpopulate.getLong("last", Long.MIN_VALUE);
    // TODO: Handle overflow properly.
    long thisPopulateStart = System.currentTimeMillis();
    long lastPopulateAgo = lastPopulateAt == Long.MIN_VALUE ? Long.MAX_VALUE
         : thisPopulateStart - lastPopulateAt;
    boolean doPopulateNow = recentMs <= 0 || lastPopulateAgo >= (long)recentMs;
    Log.e(TAG, "CONNX populatePresenceTable recentMs=" + recentMs +
               " lastPopulateAgo=" + lastPopulateAgo +
               " doPopulateNow=" + doPopulateNow);
    if (!doPopulateNow)
      return;
    presenceLock_.lock();
    try {
      // We must call getWritableDatabase with the presenceLock_ held.
      SQLiteDatabase db = getPresenceOpenHelper(context).getWritableDatabase();
      try {
        db.beginTransaction();
        try {
          populatePresenceTable(context.getContentResolver(), db);
          db.setTransactionSuccessful();
        } finally {
          db.endTransaction();
        }
      } finally {
        db.close();
      }
    } finally {
      presenceLock_.unlock();
    }
    long thisPopulateEnd = System.currentTimeMillis();
    // TODO: Log the number of entries added above.
    Log.e(TAG, "CONNX populatePresenceTable done, duration=" +
          (thisPopulateEnd - thisPopulateStart));
    context.getSharedPreferences("lastpopulate", 0).edit()
        .putLong("last", thisPopulateEnd).commit();
  }

  interface PresenceTableModifier {
    void modify(SQLiteDatabase db);
  }

  static void modifyPresenceTable(Context context, boolean doSetLastPopulate,
                                  PresenceTableModifier modifier) {
    presenceLock_.lock();
    try {
      SharedPreferences spLastpopulate = context.getSharedPreferences("lastpopulate", 0);
      long lastPopulateAt = spLastpopulate.getLong("last", Long.MIN_VALUE);
      long thisPopulateStart = System.currentTimeMillis();
      long lastPopulateAgo = lastPopulateAt == Long.MIN_VALUE ? Long.MAX_VALUE
           : thisPopulateStart - lastPopulateAt;
      Log.e(TAG, "CONNX modifyPresenceTable " +
                 " lastPopulateAgo=" + lastPopulateAgo);
      SQLiteDatabase db = getPresenceOpenHelper(context).getWritableDatabase();
      try {
        db.beginTransaction();
        try {
          modifier.modify(db);
          db.setTransactionSuccessful();
        } finally {
          db.endTransaction();
        }
      } finally {
        db.close();
      }
      if (doSetLastPopulate) {
        long thisPopulateEnd = System.currentTimeMillis();
        Log.e(TAG, "CONNX modifyPresenceTable " +
                   " done, duration=" + (thisPopulateEnd - thisPopulateStart));
        context.getSharedPreferences("lastpopulate", 0).edit()
            .putLong("last", thisPopulateEnd).commit();
      }
    } finally {
      presenceLock_.unlock();
    }
  }

  interface PresenceEntryReceiver {
    void receivePresenceEntry(String address, String body, long date, int type);
  }

  /** The caller should call db.beginTransaction().
   * @param context context
   * @param receiver receiver
   */
  static void iterPresenceTable(Context context, PresenceEntryReceiver receiver) {
    presenceLock_.lock();
    try {
      SQLiteDatabase db = getPresenceOpenHelper(context).getWritableDatabase();
      try {
        db.beginTransaction();
        try {
          Cursor cursor = db.query(
              "PresenceEntry", PresenceOpenHelper.PRESENCE_COLUMNS,
              null, null, null, null, null);
          try {
            cursor.moveToFirst();
            while (!cursor.isAfterLast()) {
              receiver.receivePresenceEntry(cursor.getString(0), cursor.getString(1),
                                            cursor.getLong(2), cursor.getInt(3));
              cursor.moveToNext();
            }
          } finally {
            cursor.close();
          }
          db.setTransactionSuccessful();
        } finally {
          db.endTransaction();
        }
      } finally {
        db.close();
      }
    } finally {
      presenceLock_.unlock();
    }
  }
}
